Привет Хабр! Я создал контрол на основе TextBlock с возможностью подсветки текста. Для начала приведу пример его использования, затем опишу, как он создавался.
<local:HighlightTextBlock TextWrapping="Wrap"> <local:HighlightTextBlock.HighlightRules> <local:HighlightRule HightlightedText="{Binding Filter, Source={x:Reference thisWindow}}"> <local:HighlightRule.Highlights> <local:HighlightBackgroung Brush="Yellow"/> <local:HighlightForeground Brush="Black"/> </local:HighlightRule.Highlights> </local:HighlightRule> </local:HighlightTextBlock.HighlightRules> <Run FontWeight="Bold">Property:</Run> <Run Text="{Binding Property}"/> </local:HighlightTextBlock>
Начало разработки
Потребовалось мне подсветить текст в TextBlock, введенный в строку поиска. На первый взгляд задача показалась простой. Пришло в голову разделить текст на 3 элемента Run, которые бы передавали в конвертер весь текст, строку поиска и свое положение (1/2/3). Средний Run имеет Backgroung.
Не успел я приступить к реализации, как пришла в голову мысль, что совпадений может быть несколько. А значит такой подход не подходит.
Была еще мысль формировать Xaml «на лету», парсить его при помощи XamlReader и кидать в TextBlock. Но эта мысль тоже сразу отвалилась, потому что попахивает.
Следующей (и окончательной) идеей стало создать систему правил подсветки и прикрутить ее к TextBlock. Тут 2 варианта: свой контрол с блэкджеком и девочками на основе TextBlock или AttachedProperty. После недолгих раздумий, я решил, что все таки лучше создать отдельный контрол, потому что функционал подсветки может наложить некоторые ограничения на функциональность самого TextBlock, а разруливать это проще, если от него унаследоваться.
Исходники готового контрола
Итак, приступим. Сразу предупрежу, что контрол я делал в том же проекте, где собирался тестировать первую идею, поэтому не обращайте внимание на неймспейсы. До ума такие вещи я доведу уже, когда буду включать контрол в основной проект (или буду выкладывать на гитхаб).
В Xaml разметке контрола все чисто, за исключением обработчика события Loaded
<TextBlock x:Class="WpfApplication18.HighlightTextBlock" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Loaded="TextBlock_Loaded"> </TextBlock>
Переходим к коду:
public partial class HighlightTextBlock : TextBlock { // Здесь сохраняется сериализованное оригинальное наполнение TextBlock // (подсветка накладывается на оригинал и потом уже подставляется в TextBlock) string _content; // Это словарь для правил подсветки и соответствующих им очередей задач Dictionary<HighlightRule, TaskQueue> _ruleTasks; /// <summary> /// Коллекция правил подсветки /// </summary> public HighlightRulesCollection HighlightRules { get { return (HighlightRulesCollection)GetValue(HighlightRulesProperty); } set { SetValue(HighlightRulesProperty, value); } } public static readonly DependencyProperty HighlightRulesProperty = DependencyProperty.Register("HighlightRules", typeof(HighlightRulesCollection), typeof(HighlightTextBlock), new FrameworkPropertyMetadata(null) { PropertyChangedCallback = HighlightRulesChanged }); static void HighlightRulesChanged(DependencyObject sender, DependencyPropertyChangedEventArgs e) { var col = e.NewValue as HighlightRulesCollection; var tb = sender as HighlightTextBlock; if (col != null && tb != null) { col.CollectionChanged += tb.HighlightRules_CollectionChanged; foreach (var rule in col) { rule.HighlightTextChanged += tb.Rule_HighlightTextChanged; } } } public HighlightTextBlock() { _ruleTasks = new Dictionary<HighlightRule, TaskQueue>(); HighlightRules = new HighlightRulesCollection(); InitializeComponent(); } // Обработчик события на изменение коллекции правил подсветки void HighlightRules_CollectionChanged(object sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e) { switch (e.Action) { case System.Collections.Specialized.NotifyCollectionChangedAction.Add: foreach (HighlightRule rule in e.NewItems) { _ruleTasks.Add(rule, new TaskQueue(1)); SubscribeRuleNotifies(rule); BeginHighlight(rule); } break; case System.Collections.Specialized.NotifyCollectionChangedAction.Remove: foreach (HighlightRule rule in e.OldItems) { rule.HightlightedText = string.Empty; _ruleTasks.Remove(rule); UnsubscribeRuleNotifies(rule); } break; case System.Collections.Specialized.NotifyCollectionChangedAction.Reset: foreach (HighlightRule rule in e.OldItems) { rule.HightlightedText = string.Empty; _ruleTasks.Remove(rule); UnsubscribeRuleNotifies(rule); } break; } } // Подписка на события правила подсветки void SubscribeRuleNotifies(HighlightRule rule) { rule.HighlightTextChanged += Rule_HighlightTextChanged; } // Отписка от событий правила подсветки void UnsubscribeRuleNotifies(HighlightRule rule) { rule.HighlightTextChanged -= Rule_HighlightTextChanged; } // Обработчик события, которое срабатывает, когда текст для подсветки изменился void Rule_HighlightTextChanged(object sender, HighlightTextChangedEventArgs e) { BeginHighlight((HighlightRule)sender); } // Здесь запускается механизм подсвечивания в созданном мною диспетчере задач. // Смысл в том, что если текст вводится/стирается слишком быстро, // предыдущая подсветка не успеет закончить работу, поэтому новая подсветка // добавляется в очередь. Если в очереди уже что то есть, то это удаляется из очереди // и вставляется новая задача. Для каждого правила очередь своя. void BeginHighlight(HighlightRule rule) { _ruleTasks[rule].Add(new Action(() => Highlight(rule))); } // Механизм подсветки void Highlight(HighlightRule rule) { // Если передали не существующее правило, покидаем процедуру if (rule == null) return; // Так как правила у нас задаются в Xaml коде, они будут принадлежать основному потоку, в котором крутится форма, // поэтому некоторые свойства можно достать/положить только таким образом ObservableCollection<Highlight> highlights = null; Application.Current.Dispatcher.Invoke(new ThreadStart(() => { highlights = rule.Highlights; })); // Даже если существует правило, но в нем не задано, чем подсвечивать, покидаем процедуру подсветки if (highlights.Count == 0) return; // Еще ряд условий для выхода из процедуры подсветки var exitFlag = false; exitFlag = exitFlag || string.IsNullOrWhiteSpace(_content); Application.Current.Dispatcher.Invoke(new ThreadStart(() => { exitFlag = exitFlag || Inlines.IsReadOnly || Inlines.Count == 0 || HighlightRules == null || HighlightRules.Count == 0; })); if (exitFlag) return; // Создадим параграф. Все манипуляции будем проводить внутри него, потому что выделить что либо // непосредственно в TextBlock нельзя, если это выделение затрагивает несколько элементов var par = new Paragraph(); // Парсим _content, в котором у нас сериализованный Span с оригинальным содержимым TextBlock'a. var parsedSp = (Span)XamlReader.Parse(_content); // Сам Span нам не нужен, поэтому сливаем все его содержимое в параграф par.Inlines.AddRange(parsedSp.Inlines.ToArray()); // Обозначаем стартовую позицию (просто для удобства) и выдергиваем из TextBlock'a голый текст. // Искать вхождения искомой строки будем именно в нем var firstPos = par.ContentStart; var curText = string.Empty; Application.Current.Dispatcher.Invoke(new ThreadStart(() => { curText = Text; })); // Выдергиваем из основного потока текст для подсветки var hlText = string.Empty; Application.Current.Dispatcher.Invoke(new ThreadStart(() => { hlText = rule.HightlightedText; })); // Если текст для подсветки не пустой и его длина не превышает длину текста, в котором ищем, // то продолжим, иначе просто выведем в конце оригинал if (!string.IsNullOrEmpty(hlText) && hlText.Length <= curText.Length) { // Выдергиваем в основном потоке из правила свойство IgnoreCase. // Решил логику оставиьт в основном потоке, потому что нагрузка операции очень низкая // и не стоит моего пота :) var comparison = StringComparison.CurrentCulture; Application.Current.Dispatcher.Invoke(new ThreadStart(() => { comparison = rule.IgnoreCase ? StringComparison.CurrentCultureIgnoreCase : StringComparison.CurrentCulture; })); // Формируем список индексов, откуда начинаются вхождения искомой строки в тексте var indexes = new List<int>(); var ind = curText.IndexOf(hlText, comparison); while (ind > -1) { indexes.Add(ind); ind = curText.IndexOf(hlText, ind + hlText.Length, StringComparison.CurrentCultureIgnoreCase); } TextPointer lastEndPosition = null; // Проходим по всем индексам начала вхождения строки поиска в текст foreach (var index in indexes) { // Эта переменная нужна была в моих соисканиях наилучшего места для начала поиска, // ведь индекс положения в string не соответствует реальному положению TextPointer'a. // Поиск продолжается, поэтому переменную я оставил. var curIndex = index; // Начинаем поиск с последней найденной позиции либо перемещаем TextPointer вперед // на значение, равное индексу вхождения подстроки в текст var pstart = lastEndPosition ?? firstPos.GetInsertionPosition(LogicalDirection.Forward).GetPositionAtOffset(curIndex); // startInd является длиной текста между начальным TextPointer и текущей точкой начала подсветки var startInd = new TextRange(pstart, firstPos.GetInsertionPosition(LogicalDirection.Forward)).Text.Length; // В результате нам нужно, чтобы startInd был равен curIndex while (startInd != curIndex) { // Если честно, мне неще не встречались случаи, когда я обгонял startInd обгонял curIndex, однако // решил оставить продвижение назад на случай более оптимизированного алгоритма поиска if (startInd < curIndex) { // Смещаем точку начала подсветки на разницу curIndex - startInd var newpstart = pstart.GetPositionAtOffset(curIndex - startInd); // Иногда TextPointer оказывается между \r и \n, в этом случае начало подсветки // сдвигается вперед. Чтобы этого избежать, двигаем его в следующую позицию для вставки if (newpstart.GetPointerContext(LogicalDirection.Forward) == TextPointerContext.ElementEnd) newpstart = newpstart.GetInsertionPosition(LogicalDirection.Forward); var len = new TextRange(pstart, newpstart).Text.Length; startInd += len; pstart = newpstart; } else { var newpstart = pstart.GetPositionAtOffset(curIndex - startInd); var len = new TextRange(pstart, newpstart).Text.Length; startInd -= len; pstart = newpstart; } } // Ищем конечную точку подсветки аналогичным способом, как для начальной var pend = pstart.GetPositionAtOffset(hlText.Length); var delta = new TextRange(pstart, pend).Text.Length; while (delta != hlText.Length) { if (delta < hlText.Length) { var newpend = pend.GetPositionAtOffset(hlText.Length - delta); var len = new TextRange(pend, newpend).Text.Length; delta += len; pend = newpend; } else { var newpend = pend.GetPositionAtOffset(hlText.Length - delta); var len = new TextRange(pend, newpend).Text.Length; delta -= len; pend = newpend; } } // К сожалению, предложенным способом не получается разделить Hyperlink. // Скорее всего это придется делать вручную, но пока такой необходимости нет, // поэтому, если начальной или конечной частью подсветки мы режем гиперссылку, // то просто сдвигаем эти позиции. В общем ссылка либо полностью попадает в подсветку, // либо не попадает совсем var sHyp = (pstart?.Parent as Inline)?.Parent as Hyperlink; var eHyp = (pend?.Parent as Inline)?.Parent as Hyperlink; if (sHyp != null) pstart = pstart.GetNextContextPosition(LogicalDirection.Forward); if (eHyp != null) pend = pend.GetNextContextPosition(LogicalDirection.Backward); // Ну а тут применяем к выделению подсветки. if (pstart.GetOffsetToPosition(pend) > 0) { var sp = new Span(pstart, pend); foreach (var hl in highlights) hl.SetHighlight(sp); } lastEndPosition = pend; } } // Здесь сериализуем получившийся параграф и в основном потоке помещаем его содержимое в TextBlock var parStr = XamlWriter.Save(par); Application.Current.Dispatcher.BeginInvoke(new ThreadStart(() => { Inlines.Clear(); Inlines.AddRange(((Paragraph)XamlReader.Parse(parStr)).Inlines.ToArray()); })).Wait(); } void TextBlock_Loaded(object sender, RoutedEventArgs e) { // Здесь дергаем наполнение TextBlock'a и сериализуем его в строку, // чтобы накатывать подсветку всегда на оригинал. // Это лучше вынести в отдельный поток, но пока и так сойдет. var sp = new Span(); sp.Inlines.AddRange(Inlines.ToArray()); var tr = new TextRange(sp.ContentStart, sp.ContentEnd); using (var stream = new MemoryStream()) { tr.Save(stream, DataFormats.Xaml); stream.Position = 0; using(var reader = new StreamReader(stream)) { _content = reader.ReadToEnd(); } } Inlines.AddRange(sp.Inlines.ToArray()); // Запускаем подсветку для всех правил foreach (var rule in HighlightRules) BeginHighlight(rule); } }
Я не буду здесь описывать код, потому что комментарии, на мой взгляд, избыточны.
Вот код очереди задач:
public class TaskQueue { Task _worker; Queue<Action> _queue; int _maxTasks; bool _deleteOld; object _lock = new object(); public TaskQueue(int maxTasks, bool deleteOld = true) { if (maxTasks < 1) throw new ArgumentException("TaskQueue: максимальное число задач должно быть больше 0"); _maxTasks = maxTasks; _deleteOld = deleteOld; _queue = new Queue<Action>(maxTasks); } public bool Add(Action action) { if (_queue.Count() < _maxTasks) { _queue.Enqueue(action); DoWorkAsync(); return true; } if (_deleteOld) { _queue.Dequeue(); return Add(action); } return false; } void DoWorkAsync() { if(_queue.Count>0) _worker = Task.Factory.StartNew(DoWork); } void DoWork() { lock (_lock) { if (_queue.Count > 0) { var currentTask = Task.Factory.StartNew(_queue.Dequeue()); currentTask.Wait(); DoWorkAsync(); } } } }
Здесь все довольно просто. Поступает новая задача. Если в очереди есть место, то она помещается в очередь. Иначе, если поле _deleteOld == true, то удаляем следующую задачу (наиболее позднюю) и помещаем новую, иначе возвращаем false (задача не добавлена).
Вот код коллекции правил. По идее, можно было обойтись ObservableCollection, но от этой коллекции в дальнейшем может потребоваться дополнительный функционал.
public class HighlightRulesCollection : DependencyObject, INotifyCollectionChanged, ICollectionViewFactory, IList, IList<HighlightRule> { ObservableCollection<HighlightRule> _items; public HighlightRulesCollection() { _items = new ObservableCollection<HighlightRule>(); _items.CollectionChanged += _items_CollectionChanged; } public HighlightRule this[int index] { get { return ((IList<HighlightRule>)_items)[index]; } set { ((IList<HighlightRule>)_items)[index] = value; } } object IList.this[int index] { get { return ((IList)_items)[index]; } set { ((IList)_items)[index] = value; } } public int Count { get { return ((IList<HighlightRule>)_items).Count; } } public bool IsFixedSize { get { return ((IList)_items).IsFixedSize; } } public bool IsReadOnly { get { return ((IList<HighlightRule>)_items).IsReadOnly; } } public bool IsSynchronized { get { return ((IList)_items).IsSynchronized; } } public object SyncRoot { get { return ((IList)_items).SyncRoot; } } public event NotifyCollectionChangedEventHandler CollectionChanged; public int Add(object value) { return ((IList)_items).Add(value); } public void Add(HighlightRule item) { ((IList<HighlightRule>)_items).Add(item); } public void Clear() { ((IList<HighlightRule>)_items).Clear(); } public bool Contains(object value) { return ((IList)_items).Contains(value); } public bool Contains(HighlightRule item) { return ((IList<HighlightRule>)_items).Contains(item); } public void CopyTo(Array array, int index) { ((IList)_items).CopyTo(array, index); } public void CopyTo(HighlightRule[] array, int arrayIndex) { ((IList<HighlightRule>)_items).CopyTo(array, arrayIndex); } public ICollectionView CreateView() { return new CollectionView(_items); } public IEnumerator<HighlightRule> GetEnumerator() { return ((IList<HighlightRule>)_items).GetEnumerator(); } public int IndexOf(object value) { return ((IList)_items).IndexOf(value); } public int IndexOf(HighlightRule item) { return ((IList<HighlightRule>)_items).IndexOf(item); } public void Insert(int index, object value) { ((IList)_items).Insert(index, value); } public void Insert(int index, HighlightRule item) { ((IList<HighlightRule>)_items).Insert(index, item); } public void Remove(object value) { ((IList)_items).Remove(value); } public bool Remove(HighlightRule item) { return ((IList<HighlightRule>)_items).Remove(item); } public void RemoveAt(int index) { ((IList<HighlightRule>)_items).RemoveAt(index); } IEnumerator IEnumerable.GetEnumerator() { return ((IList<HighlightRule>)_items).GetEnumerator(); } void _items_CollectionChanged(object sender, NotifyCollectionChangedEventArgs e) { CollectionChanged?.Invoke(this, e); } }
Вот код правила подсветки:
public class HighlightRule : DependencyObject { public delegate void HighlightTextChangedEventHandler(object sender, HighlightTextChangedEventArgs e); public event HighlightTextChangedEventHandler HighlightTextChanged; public HighlightRule() { Highlights = new ObservableCollection<Highlight>(); } /// <summary> /// Текст, который нужно подсветить /// </summary> public string HightlightedText { get { return (string)GetValue(HightlightedTextProperty); } set { SetValue(HightlightedTextProperty, value); } } public static readonly DependencyProperty HightlightedTextProperty = DependencyProperty.Register("HightlightedText", typeof(string), typeof(HighlightRule), new FrameworkPropertyMetadata(string.Empty, HighlightPropertyChanged)); public static void HighlightPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { var me = d as HighlightRule; if (me != null) me.HighlightTextChanged?.Invoke(me, new HighlightTextChangedEventArgs((string)e.OldValue, (string)e.NewValue)); } /// <summary> /// Игнорировать регистр? /// </summary> public bool IgnoreCase { get { return (bool)GetValue(IgnoreCaseProperty); } set { SetValue(IgnoreCaseProperty, value); } } public static readonly DependencyProperty IgnoreCaseProperty = DependencyProperty.Register("IgnoreCase", typeof(bool), typeof(HighlightRule), new PropertyMetadata(true)); /// <summary> /// Коллекция подсветок /// </summary> public ObservableCollection<Highlight> Highlights { get { return (ObservableCollection<Highlight>)GetValue(HighlightsProperty); } set { SetValue(HighlightsProperty, value); } } public static readonly DependencyProperty HighlightsProperty = DependencyProperty.Register("Highlights", typeof(ObservableCollection<Highlight>), typeof(HighlightRule), new PropertyMetadata(null)); } public class HighlightTextChangedEventArgs : EventArgs { public string OldText { get; } public string NewText { get; } public HighlightTextChangedEventArgs(string oldText,string newText) { OldText = oldText; NewText = newText; } }
Никакой логики тут нет почти, поэтому без комментариев.
Вот абстрактный класс для подсветки:
public abstract class Highlight : DependencyObject { public abstract void SetHighlight(Span span); public abstract void SetHighlight(TextRange range); }
Мне на данный момент известно два способа подсветить фрагмент. Через Span и через TextRange. Пока что выбранный способ железно прописан в коде в процедуре подсветки, но в дальнейшем я планирую сделать это опционально.
public class HighlightBackgroung : Highlight { public override void SetHighlight(Span span) { Brush brush = null; Application.Current.Dispatcher.BeginInvoke(new ThreadStart(() => { brush = Brush; })).Wait(); span.Background = brush; } public override void SetHighlight(TextRange range) { Brush brush = null; Application.Current.Dispatcher.BeginInvoke(new ThreadStart(() => { brush = Brush; })).Wait(); range.ApplyPropertyValue(TextElement.BackgroundProperty, brush); } /// <summary> /// Кисть для подсветки фона /// </summary> public Brush Brush { get { return (Brush)GetValue(BrushProperty); } set { SetValue(BrushProperty, value); } } public static readonly DependencyProperty BrushProperty = DependencyProperty.Register("Brush", typeof(Brush), typeof(HighlightBackgroung), new PropertyMetadata(Brushes.Transparent)); }
Ну тут нечего комментировать, кроме безопасности потоков. Дело в том, что экземпляр должен крутиться в основном потоке, а метод может быть вызван откуда угодно.
public class HighlightForeground : Highlight { public override void SetHighlight(Span span) { Brush brush = null; Application.Current.Dispatcher.BeginInvoke(new ThreadStart(() => { brush = Brush; })).Wait(); span.Foreground = brush; } public override void SetHighlight(TextRange range) { Brush brush = null; Application.Current.Dispatcher.BeginInvoke(new ThreadStart(() => { brush = Brush; })).Wait(); range.ApplyPropertyValue(TextElement.ForegroundProperty, brush); } /// <summary> /// Кисть для цвета текста /// </summary> public Brush Brush { get { return (Brush)GetValue(BrushProperty); } set { SetValue(BrushProperty, value); } } public static readonly DependencyProperty BrushProperty = DependencyProperty.Register("Brush", typeof(Brush), typeof(HighlightForeground), new PropertyMetadata(Brushes.Black)); }
Заключение
Ну вот пожалуй и все. Хотелось бы услышать ваше мнение.
ссылка на оригинал статьи https://habrahabr.ru/post/314060/
Добавить комментарий